今天大概會聊到的範圍
- draggable
 - pointerInput
 
前兩天在 Feedy 上看文章順便想靈感時,突然發現這個行為:

列表項目可以左右拖移並展示出更多功能。我覺得這個行為很適合現在的我來仿造學習:有 State 概念、有 Animation 又有 Gesture。
從最單純的一個畫面開始吧,簡單的文字 + LazyColumn 組成的一個 List,仿造 Feedy 的列表。
@Composable
fun Item(title: String = "", content: String = "") {
    Card(
        backgroundColor = black,
        modifier = Modifier
            .clip(RoundedCornerShape(4.dp))
            .fillMaxWidth()
            .padding(horizontal = 16.dp, vertical = 6.dp)
    ) {
        Column {
            
            Text(
                title,
                color = Color.White,
                style = titleStyle,
            )
            
            Text(
                content,
                color = Color.White,
                style = contentStyle,
                softWrap = true,
                maxLines = 4,
                overflow = TextOverflow.Ellipsis
            )
        }
    }
}
@Composable
fun ListScreen() {
    
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(horizontal = 8.dp)
            .background(color = blackBgColor)
    ) {
        
        val repo = RandomWordRepo()
        
        LazyColumn {
            items(repo.randomContent) {
                val (title, content) = it
                Item(title = title, content = content)
            }
        }
        
    }
}

在 Gesture modifier 中,我們有 draggable 這個 modifier 可以表達拖拉的行為。
@Composable
fun DraggableItem(title: String = "", content: String = "") {
    
    Box(modifier = Modifier.draggable(
        orientation = Orientation.Horizontal,
        state = rememberDraggableState {
            Log.d("DraggableItem", "DraggableItem: $it")
        }
    )) {
        Item(title = title, content = content)
    }
}
在使用 draggable 時,我們要提供兩個資料:orientation 表示可以拉動的維度(水平還是垂直)、state 則是拉動距離的資料載體,使用 rememberDraggableState 可以取得 delta 值 (拉動距離) 作為 callback 使用
目前這個 Item 已經可以被拉動了(Log 會有資料),但是畫面不會跟著動。這時我們要將拉動距離和物件的位置做連動
@Composable
fun DraggableItem(title: String = "", content: String = "") {
    
    // 增加一個 state 紀錄 offsetX
    var offsetX by remember { mutableStateOf(0f) }
    
    Box(modifier = Modifier
        .offset { IntOffset(x = offsetX.roundToInt(), y = 0) }
        .draggable(
            orientation = Orientation.Horizontal,
            state = rememberDraggableState {
                offsetX += it   // <--- 每次都將 drag 的距離加上 x offset
            }
        )) {
        Item(title = title, content = content)
    }
}

現在我們可以拉動 Item 了,但這個 Item 可以任意地被拉動,這不是我們所希望的。所以我在 offset {} 加上 maxOffset 的判斷
.offset {
        
    val maxOffset = 100.dp.toPx()
        
    val x =
        when {
            offsetX > maxOffset -> maxOffset
            offsetX < -maxOffset -> -maxOffset
            else -> offsetX
        }
        
    IntOffset(
        x = x.roundToInt(),
        y = 0
    )
}

現在 Item 可以被拉動了,但是並不會回到原位。因此我們需要一個讓 Item 回到原先的位置的行為。
我希望當手指離開螢幕的時候,可以做回彈的動作。因此,我希望在 drag 結束時將 offsetX 設定回 0
.draggable(
    orientation = Orientation.Horizontal,
    state = rememberDraggableState {
        offsetX += it
    },
    onDragStopped = { offsetX = 0f }
)
整個 drag 行為,也可以透過 pointerInput 來達成。pointerInput 是另一個 gesture modifier,他可以取得較 low-level 的互動數據並加以操作

最後,我想在 Item 移動時加上動畫。基本上我只需要將 offsetX 這個 float 透過 animateFloatAsState 做一次轉換就可以了。
var offsetX by remember { mutableStateOf(0f) }
    
val offsetXAnimate by animateFloatAsState(targetValue = offsetX)
接下來我們將所有用到 offsetX 的部分都改成 offsetXAnimate 就大功告成了!

Reference: